package de.zib.gndms.stuff.confuror;
import org.codehaus.jackson.JsonNode;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.node.ArrayNode;
import org.codehaus.jackson.node.ObjectNode;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.util.Iterator;
import java.util.Stack;
/**
* ConfigEditor
*
* Helper for applying updates to a json tree
*
* @see ConfigEditor#update(org.codehaus.jackson.JsonNode, org.codehaus.jackson.JsonNode)
*
* @author try ste fan pla nti kow zib
*/
public final class ConfigEditor {
/**
* Implemented by visitors that want to allow/deny/modify updates to the tree
*
* @see Update
*/
public static interface Visitor {
public ObjectMapper getObjectMapper();
public void updateNode(@NotNull Update updater);
}
/**
* Single update to a node
*/
@SuppressWarnings({"UnusedDeclaration"})
public static interface Update {
/**
* @return Original node to be updated
*/
@NotNull JsonNode getOriginal();
/**
* Returns an Update! (i.e. field names may contain +, -)
*
* @return Replacement node
*/
@NotNull JsonNode getUpdate();
/**
* @return Path to original from root (String and int cursors)
*/
Object[] getPath();
/**
* @return true, if the update will be accepted
*/
boolean isAccepted();
void setAccepted(boolean acceptedState);
/**
* Accept and change update
*
* @param finalUpdate the replacement node
*/
void replace(@NotNull JsonNode finalUpdate);
void accept();
void reject();
/**
* @return the update mode
*/
@NotNull Mode getMode();
enum Mode { OVERWRITE, APPEND, DELETE }
}
private final @NotNull Visitor visitor;
public ConfigEditor(@NotNull Visitor visitor) {
this.visitor = visitor;
}
/**
* Apply the update diff to node subject to the following rules. For every singular node that is updated, the
* ConfigEditor.Visitor of this ConfigEditor is called.
*
* The toplevel is expected to be an object or an array or null (initially only).
*
* Updates to the toplevel are always appended, i.e. the toplevel is never fully overwritten.
*
* Sublevels are always overwritten unless all keys on the path to the current level are all prefixed with '+'.
* (However the final key to the value must not be prefixed with '+'!)
*
* To delete, prefix the key of the entry that is to be deleted with '-'
*
* @param node the root node to be updated
* @param update the update to be applied to the root node
* @return the updated node
*
* @throws UpdateRejectedException if anything goes wrong or the visitor vetos
*/
@SuppressWarnings({"UnnecessaryContinue"})
public @NotNull JsonNode update(@NotNull JsonNode node, @NotNull JsonNode update) throws UpdateRejectedException {
if (! (node.isObject() || node.isArray() || node.isNull()))
throw new IllegalArgumentException("Toplevel node is neither an object nor an array nor null");
final Stack<Record> records = new Stack<Record>();
final Record topRecord = new Record(node, update, null, null, Update.Mode.APPEND);
records.push(topRecord);
try { while (! records.empty()) {
final Record rec = records.pop();
// handles nodes of different types and null snapshot causes by missing pieces
if (rec.snapshot == null || rec.snapshot.getClass() != rec.update.getClass()) {
rec.visit(records, visitor);
continue;
}
// find differences and call visitor as necessary
// (1) Handle value nodes
if (rec.snapshot.isValueNode()) {
if (! rec.snapshot.equals(rec.update))
// replace
rec.visit(records, visitor);
continue;
}
// (2) Handle array nodes
if (rec.snapshot.isArray()) {
if ((rec.snapshot.size() != rec.update.size()) || (! Update.Mode.APPEND.equals(rec.mode)))
// replace if update is of different size or we're not in APPEND
rec.visit(records, visitor);
else {
// update all childs
for (int i = 0; i < rec.update.size(); i++)
records.push(new Record(rec.snapshot.get(i), rec.update.get(i), rec, i,
Update.Mode.OVERWRITE));
}
continue;
}
// (3) Handle object nodes
if (rec.snapshot.isObject()) {
if (! Update.Mode.APPEND.equals(rec.mode)) {
// replace if we're not in APPEND
rec.visit(records, visitor);
}
else {
// handle childs according to mode selected by key prefix
for (final Iterator<String> iter = rec.update.getFieldNames(); iter.hasNext(); ) {
final String fieldName = iter.next();
switch (fieldName.charAt(0)) {
case '+': {
final String snapshotFieldName = fieldName.substring(1);
final JsonNode snapshot = rec.snapshot.get(snapshotFieldName);
if (snapshot != null && snapshot.isObject())
records.push(new Record(snapshot, rec.update.get(fieldName),
rec, snapshotFieldName, Update.Mode.APPEND));
else
throw new IllegalArgumentException("No updatable hash found");
}
break;
case '-': {
final String snapshotFieldName = fieldName.substring(1);
new Record(rec.snapshot.get(snapshotFieldName), null,
rec, snapshotFieldName, Update.Mode.DELETE).visit(records, visitor);
}
break;
default:
records.push(new Record(rec.snapshot.get(fieldName), rec.update.get(fieldName),
rec, fieldName, Update.Mode.OVERWRITE));
}
}
}
continue;
}
throw new IllegalArgumentException("Unknown node type encountered");
} }
catch (IllegalArgumentException iae) { throw new UpdateRejectedException(iae); }
catch (IOException ioe) { throw new UpdateRejectedException(ioe);}
return topRecord.snapshot;
}
/**
* Internal stack record
*
* @see ConfigEditor#update(org.codehaus.jackson.JsonNode, org.codehaus.jackson.JsonNode)
*/
private static class Record {
private JsonNode snapshot;
private JsonNode update;
private Record parent;
private Object cursor;
private int depth = 0;
private Update.Mode mode = Update.Mode.OVERWRITE;
Record(JsonNode snapshot, JsonNode update, Record parent, Object cursor, Update.Mode mode) {
this.snapshot = snapshot;
this.update = update;
this.parent = parent;
this.cursor = cursor;
if (parent != null)
this.depth = parent.depth + 1;
this.mode = mode;
}
/**
* Ask visitor to verify the update corresponding to this record and if the visitor doesn't veto, apply it.
*
* @param records record stack used by current update call
* @param visitor the visitor used to verify updates
*
* @throws UpdateRejectedException if the visitor vetos
* @throws IOException on parsing problems
*/
void visit(Stack<Record> records, Visitor visitor) throws UpdateRejectedException, IOException {
final Update updater = this.updater();
visitor.updateNode(updater);
if (updater.isAccepted()) {
if (parent == null)
// toplevel neeeds special handling to create null node or replace
snapshot = Update.Mode.DELETE.equals(updater.getMode()) ?
ConfigHolder.newNullNode(visitor.getObjectMapper()) : updater.getUpdate();
else {
if (parent.snapshot.isArray())
((ArrayNode)parent.snapshot).set((Integer)cursor,
(Update.Mode.DELETE.equals(updater.getMode()) ?
ConfigHolder.newNullNode(visitor.getObjectMapper()) : updater.getUpdate()));
else {
if (parent.snapshot.isObject()) {
switch(updater.getMode()) {
case OVERWRITE:
((ObjectNode)parent.snapshot).put((String) cursor, updater.getUpdate());
break;
case APPEND:
for (final Iterator<String> iter = update.getFieldNames(); iter.hasNext(); ) {
String fieldName = iter.next();
ObjectNode objSnapshot = ((ObjectNode)snapshot);
records.push(
new Record(objSnapshot.get(fieldName), update.get(fieldName), this,
fieldName, Update.Mode.OVERWRITE));
}
break;
case DELETE:
((ObjectNode)parent.snapshot).remove((String) cursor);
break;
default:
throw new IllegalStateException("Should never have been reached");
}
} else
throw new IllegalStateException("Update of non-container node");
}
}
}
else
throw new UpdateRejectedException();
}
/**
*
* @return new updater suitable for use with this record only
*/
Update updater() {
return new DefaultNodeUpdater() {
@NotNull
public JsonNode getOriginal() {
return snapshot;
}
@NotNull
public JsonNode getUpdate() {
if (Mode.DELETE.equals(getMode()))
throw new IllegalStateException("Can't get update for delete operation");
return update;
}
public Object[] getPath() {
final Object[] path = new Object[depth];
Record cur = Record.this;
for (int i = depth; i > 0; i--) {
path[i-1] = cur.cursor;
cur = cur.parent;
}
return path;
}
@Override
public void setUpdate(@NotNull JsonNode finalUpdate) {
update = finalUpdate;
super.setUpdate(finalUpdate);
}
@NotNull
public Mode getMode() {
return Record.this.mode;
}
};
}
}
/**
* Default implementation of ConfigEditor.Visitor
*
* @see Visitor
*
* @author Stefan Plantikow
*/
@SuppressWarnings({"UnusedDeclaration"})
public static class DefaultVisitor implements Visitor {
private ObjectMapper objectMapper;
public void updateNode(@NotNull Update updater) {
updater.accept();
}
public ObjectMapper getObjectMapper() {
return objectMapper;
}
public void setObjectMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
}
/**
* Default implementation of an Update
*
* @see Update
*
* @author Stefan Plantikow
*/
public static abstract class DefaultNodeUpdater implements Update {
private boolean accepted = true;
public boolean isAccepted() {
return accepted;
}
public void setAccepted(boolean acceptedState) {
accepted = acceptedState;
}
public void setUpdate(@NotNull JsonNode finalUpdate) {
if (Mode.DELETE.equals(getMode()))
throw new IllegalStateException("Can't set update for delete operation");
setAccepted(true);
}
public final void replace(@NotNull JsonNode finalUpdate) {
setUpdate(finalUpdate);
}
public final void accept() {
setAccepted(true);
}
public final void reject() {
setAccepted(false);
}
}
/**
* Thrown to indicate that an update could not be applied by ConfigEditor
*
* @author Stefan Plantikow
*/
@SuppressWarnings({"UnusedDeclaration"})
public static class UpdateRejectedException extends Exception {
private static final long serialVersionUID = 6028588409754411491L;
public UpdateRejectedException() {
}
public UpdateRejectedException(String s) {
super(s);
}
public UpdateRejectedException(String s, Throwable throwable) {
super(s, throwable);
}
public UpdateRejectedException(Throwable throwable) {
super(throwable);
}
}
}